Grupo 03:
Steam es una plataforma de distribución digital de videojuegos, software y contenido multimedia creada por Valve Corporation, enfocada en entregar dichos contenidos para computadores con sistemas operativos Windows, macOS y Linux.
Esta aplicación garantiza un ecosistema donde jugadores de todos lados del mundo puedan interactuar entre ellos mediante foros públicos, conversaciones por grupos/comunidades e incluso chats privados con tus amigos, sin mencionar opciones como juego multijugador en línea, compartir juegos entre amigos y un entorno virtual de multijugador local (en resumen, poder jugar un multijugador local mediante una conexión a un amigo).
Sumado a esto, constantemente buscan estimular la competitividad entre sus usuarios mediante opciones como estadísticas de juego, marcador de horas jugadas, integración de logros (con la opción de añadir logros "ocultos": no se menciona cómo obtenerlos hasta que el jugador cumple los requerimientos para conseguirlo), entre otros.
Finalmente, poseen herramientas como la recomendación de juegos por usuarios, la cual permite que los jugadores puedan dar a conocer sus opiniones, dando espacio para la opinión pública, de manera que pueden recomendar o no recomendar un juego, con la opción de incluir un párrafo donde argumentar su decisión.
La industria de los videojuegos está en constante crecimiento, con un aumento significativo en el número de jugadores a nivel mundial, generando una mayor demanda y expectativas por parte de los consumidores hacia las empresas desarrolladoras de videojuegos. Esto a su vez ha provocado un aumento en la cantidad de juegos lanzados en el mercado, desde aquellas producciones de las distribuidoras más importanes del mercado (conocidas como AAA) hasta los creados por desarrolladores independientes (indies). Lo anterior puede llevar a los desarrolladores de juegos preguntarse: ¿Cómo puedo hacer que mi proyecto se venda en un mercado tan saturado?
Como grupo, creemos que es fundamental analizar previamente el sector del mercado al que un desarrollador desea dirigirse considerando el tipo de producto que desea lanzar, si posee o no una empresa que lo respalde (publisher), entre otras cosas. Por lo anterior, mediante un proyecto de minería de datos sobre DataSets de Steam, buscamos proporcionar información valiosa que permita a los desarrolladores tomar decisiones informadas y aumentar sus probabilidades de éxito de ventas al lanzar su juego en la misma plataforma.
Para la exploración de datos se usarán dos DataSets:
Steam Games Dataset: Contiene información sobre cada juego (género de juego, precio, cantidad de reviews, etc).
Steam Reviews Dataset 2021: Contiene información más detallada sobre las reviews de usuarios por juego, incluyendo la reseña que escribió el usuario acerca de un producto.
A pesar de que las tablas tienen distintos atributos, podemos realizar Joins para unirlas, ya que comparten el atributo app_id (ID el cual es dado por Steam y es único para cada juego). Cada destacar que dado el tamaño de este dataset, solo usaremos un cuarto de los datos, que son alrededor de 12 millones de filas.
# @title 2.1 Imports
from nltk.corpus import stopwords
from wordcloud import WordCloud
import matplotlib.pyplot as plt
import plotly.express as px
import pandas as pd
import numpy as np
import os
import csv
import json
import nltk
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
from sklearn.utils.multiclass import unique_labels
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, mean_absolute_error ,r2_score
# Codigo para que la parte interactiva se vea en el HTML
import plotly.io as pio
pio.renderers.default='notebook'
# @title 2.2 Exportación de Drive
from google.colab import drive
drive.mount('/content/drive')
Mounted at /content/drive
Primero cargamos los datos que serán usados para nuestra exploración de datos.
# @title 2.3 Carga de DataFrames
df_games = pd.read_csv('/content/drive/MyDrive/CC5205/games.csv', encoding="UTF-8")
df_steam_reviews = pd.read_csv('/content/drive/MyDrive/CC5205/steam_reviews.csv', encoding="UTF-8", nrows=12043389)
# @title 2.4 WordCloud
# Asegurarse de que la columna 'release_date' esté en formato de fecha
df_games['release_date'] = pd.to_datetime(df_games['release_date'], errors='coerce')
# Extraer el año de la fecha de lanzamiento
df_games['year'] = df_games['release_date'].dt.year
# Filtrar los datos para eliminar aquellos sin año de lanzamiento válido
df_games = df_games.dropna(subset=['year'])
# Obtener los años únicos y ordenarlos
years = sorted(df_games['year'].unique())
# Crear una nube de palabras para cada año
for year in years:
# Filtrar los juegos por año
juegos_ano = df_games[df_games['year'] == year]
# Crear un diccionario para contar las frecuencias de cada género
genero_frecuencias = {}
# Contar las frecuencias de cada género
for generos in juegos_ano['genres']:
if pd.notna(generos): # Verificar que no sea NaN
for genero in generos.split(','):
genero = genero.strip() # Quitar espacios en blanco alrededor
if genero in genero_frecuencias:
genero_frecuencias[genero] += 1
else:
genero_frecuencias[genero] = 1
# Generar la nube de palabras usando las frecuencias
wordcloud = WordCloud(width=800, height=400, background_color='white').generate_from_frequencies(genero_frecuencias)
# Mostrar la nube de palabras
plt.figure(figsize=(10, 5))
plt.imshow(wordcloud, interpolation='bilinear')
plt.title(f'Nube de Palabras de Géneros para el Año {int(year)}')
plt.axis('off') # Desactivar los ejes
plt.show()
En la figura tenemos plasmados distintos WordClouds para los géneros de videojuegos que hayan sido lanzados entre 1997 y 2025, ordenados por año de salida.
Como primera iteración, se decidió hacer un WordCloud para los juegos en general, sin ningún filtro asociado. El resultado fue un gráfico muy denso, lo que hacía difícil intentar deducir algo. Además, la forma en la que se realizó el WordCloud consideraba juegos con más de un género como géneros únicos (ej: si un juego tenía los géneros “Indie” y “Action”, el WordCloud lo mostraba como “Indie Action”, lo cual era erróneo).
Por ello se decidió separarlo por años, pues permite captar tendencias de géneros populares a lo largo del tiempo, además de identificar cuándo se popularizó un cierto género en caso de querer hacer una investigación más dirigida. Finalmente, arreglamos los géneros de cada juego para separarlos por comas, separándolos como “Indie Action” en sus componentes, “Indie” y “Action”.
De estos datos podemos ver una clara tendencia de géneros, como lo fue el surgimiento de los juegos independientes (indie) lo que se puede relacionar a la facilidad de acceso a un computador durante los últimos años en comparación a 25 años atrás, además de un aumento en el material de aprendizaje para crear videojuegos. Otro caso de estas tendencias son los juegos Early access, siendo productos no terminados que se lanzan al mercado, donde los desarrolladores trabajan junto a los jugadores para recibir feedback y arreglar errores.
# @title 2.5 Diagrama de Dispersión
# Crear el scatter plot
fig_scatter = px.scatter(df_games,
x='price',
y='recommendations',
color='metacritic_score',
title='Scatter Plot: Precio vs Número Total de Reseñas',
labels={'price': 'Precio (USD)', 'recommendations': 'Número Total de Recomendaciones'})
fig_scatter.show()
Para este análisis se buscará una posible relación entre el precio de los juegos y la cantidad de reviews de los usuarios. La mejor herramienta para estudiar esto es un diagrama de dispersión. Además, se mostrarán los outliers para dar cuenta de situaciones particulares dentro de la plataforma.
Sumado a esto, dicho diagrama de dispersión representará la ubicación del juego en el gráfico con un punto coloreado según su puntuación en Metacritic.
Metacritic es una página que recolecta las reseñas de algunos de los mejores críticos de las industrias de la televisión, cine, videojuegos, entre otros, para luego entregar un puntaje guiado por estas. Es muy útil en este contexto revisar la puntuación que un juego puede tener pues las puntuaciones entregadas por esta página suelen influir en la opinión del público general a la hora de valorar un nuevo lanzamiento, a la vez que entrega cierta credibilidad a dicha puntuación, útil para cuando un consumidor busca comprar un juego nuevo y necesita una opinión respaldada.
Del gráfico podemos notar que todos los juegos sobre los 100 dólares en adelante tiene una puntuación alrededor de 0 ya sea por una mala puntuación de parte de metacritic o simplemente no fueron puntuados, lo anterior puede deberse al sobreprecio sobre los mismos. Por otro lado, notamos que juegos con puntuaciones altas en Metacritic suelen ser juegos de no más de 60 dólares (que suele ser el precio estándar para producciones de grandes empresas) y con al menos 10 mil reseñas. En relación a lo anterior se observa una tendencia en donde a menor precio posee un juego mayor es su número de reseñas en la plataforma, esto se debe a la facilidad que hay para acceder al producto.
# @title 2.6 Diagrama de Dispersión con límites
# Crear el scatter plot
fig_scatter = px.scatter(df_games,
x='price',
y='recommendations',
color='metacritic_score',
title='Scatter Plot: Precio vs Número Total de Reseñas',
labels={'price': 'Precio (USD)', 'recommendations': 'Número Total de Recomendaciones'},
range_x=[0,100],
range_y=[1,1000000])
fig_scatter.show()
Se muestra el mismo gráfico que en la parte anterior pero con la eliminación de outliers. En este gráfico se muestra una clara tendencia a establecer precios en múltiplos de cinco y estar por debajo de los 30 dólares. La diferencias de precios que se observan podrían depender de la producción detrás de estos, por ejemplo: un juego con un publisher reconocido detrás tendrá, por lo general, un precio de 60 dólares; un juego hecho por desarrolladores independientes rara vez superará los 40 dólares.
# @title 2.7 Histograma de tiempo de juego por rango de precios
# Agrupa los datos por rango de precios
price_bins = [0, 10, 20, 30, 40, 50, 60, 70, 80]
df_games['price_range'] = pd.cut(df_games['price'], bins=price_bins)
# Calcula la media del tiempo de juego promedio para cada rango de precios
games_price_range = df_games.groupby('price_range')['average_playtime_forever'].mean().reset_index()
# Convierte el tiempo de juego de minutos a horas
games_price_range['average_playtime_forever'] = games_price_range['average_playtime_forever'] / 60
# Convierte los intervalos a cadenas
games_price_range['price_range'] = games_price_range['price_range'].astype(str)
# Crea el histograma
fig = px.bar(games_price_range, x='price_range', y='average_playtime_forever',
title='Promedio de tiempo de juego por rango de precios',
labels={'price_range': 'Rango de precios', 'average_playtime_forever': 'Promedio de tiempo de juego (horas)'},
color_discrete_sequence=['orange'])
# Muestra el histograma
fig.show()
Podemos notar que el promedio de horas de juego incrementa conforme sube el precio, alcanzando su punto máximo en los 60 dólares, que es el precio estándar máximo en la industria de los videojuegos. Esta información puede ser útil para que los desarrolladores estimen cuántas horas debería durar su juego ó cuanto podrían cobrar por él.
# @title 2.7 WordCloud sobre palabras presentes en reseñas de juegos del género "Farming sim"
# Descargar stopwords en inglés
nltk.download('stopwords')
english_stopwords = set(stopwords.words('english'))
# Agregar las palabras específicas a las stopwords
custom_stopwords = {'game', 'really', 'play', 'like', 'thing', 'would', 'get', 'one', 'even', 'good', 'want', "I'm", 'much', 'make', 'still'}
all_stopwords = english_stopwords.union(custom_stopwords)
df_jrpg = df_games[df_games['tags'].str.contains('Farming Sim', na=False)]
id_jrpgs = df_jrpg['app_id']
df_steam_reviews_english = df_steam_reviews[(df_steam_reviews['language'] == 'english') & (df_steam_reviews['app_id'].isin(id_jrpgs))]
def create_word_cloud(reviews, title, max_words, stopwords):
# Remover palabras vacías que no agregan nada al gráfico
def remove_stopwords(text):
words = text.split()
filtered_words = [word for word in words if word.lower() not in stopwords]
return ' '.join(filtered_words)
cleaned_reviews = [remove_stopwords(review) for review in reviews]
text = ' '.join(cleaned_reviews)
wordcloud = WordCloud(width=800, height=400, max_words=max_words, background_color='white', stopwords=stopwords).generate(text)
plt.figure(figsize=(8, 4))
plt.imshow(wordcloud, interpolation='bilinear')
plt.title(title + ' Word Cloud')
plt.axis('off')
plt.show()
positive_reviews = df_steam_reviews_english[df_steam_reviews_english['recommended'] == True]['review'].astype(str).sample(n=1000).tolist()
negative_reviews = df_steam_reviews_english[df_steam_reviews_english['recommended'] == False]['review'].astype(str).sample(n=1000).tolist()
create_word_cloud(positive_reviews, 'Reviews positivas para Farming simulator', 1000, all_stopwords)
create_word_cloud(negative_reviews, 'Reviews negativas para Farming simulator', 1000, all_stopwords)
[nltk_data] Downloading package stopwords to /root/nltk_data... [nltk_data] Unzipping corpora/stopwords.zip.
Obteniedo los comentarios en inglés para un juego del género Farming Sim podemos armar un WordCloud para ver cuales son las palabras más usadas al momento de reseñar un juego.
Se puede observar que la reseñas positivas mencionan frecuentemente Harvest Moon y Stardew Valley, juegos los cuales se podrían utilizar por un desarrollador de videojuegos como referencias para crear juegos de este género. Por otra parte, para las reseñas negativas se aprecian palabras como "graphic" (calidad gráfica), "time", "bug", "boring", etc. Estás últimas son palabras a tomar en cuenta a la hora de trabajar ya que si se descuidan estos aspectos podrían llevar a que el producto tenga malas reseñas y con ello bajas ventas.
A partir de nuestra motivación y lo encontrado en la exploración de datos, como grupo nos surgieron las siguientes preguntas que encontramos interesantes para estudiar el DataSet:
Para responder a esta pregunta haremos uso de clasificadores que usen distintos pares de atributos cada uno con el objetivo de medir cual de ellos tiene mejor desempeño que el resto.
En primer lugar creamos la función run_classifier(clf, X, Y, num_test=100) la cuál se encargará de entrenar un modelo a partir de un clasificador clf para predecir la variable y en función de X. Cabe destacar que se usará el 30% del conjunto de datos para entrenar el modelo y el porcentaje restante para evaluar el desempeño del mismo. Finalmente, para medir el desempeño de cada clasificador se usará el promedio de las métricas precision, recall y f1-score.
Para la comparación de los distintos clasificadores usaremos:
Es un clasificador muy simple ya que genera predicciones ignorando las características del input. Solo se usa como punto de comparación para clasificadores más complejos.
Utiliza un arból de decisión como estructura, en donde cada nodo interno representa una decisión sobre una característica.
Está basado en el Teorema de Bayes, el cual asume independecia entre las variables. En específico, el tipo Gaussian asume que los datós numéricos presentan una distribución normal.
Clasifica un dato según las clases de sus K vecinos más cercanos. Suele utilizar la distancia euclidiana como métrica.
Luego de obtener los resultados, graficaremos usando una matriz de confusión los resultados del clasificador con mejor desempeño, esto con el objetivo de facilitar la visualización de los datos.
Si una persona quiere desarrollar un videojuego del género "Action" el 2025, sería útil saber cuántos videojuegos del mismo género saldrán ese año para tener una idea de contra cuántos videojuegos distintos tendrá que competir el producto. Pero, ¿Podemos predecir la cantidad de juegos del género que saldrán ese año?
Preprocesamiento:
La columna "release_date" del DataSet de Steam contiene la fecha de salida del videojuego con el formato: "mes día, año", por lo que habría que crear una columna que contenga solamente el año de salida.
La columna "genres" contiene elementos de la forma: "género1, género2, género3", por lo que tendríamos que extraer solamente las filas que contengan la palabra "x" en su género.
Agrupar los videojuegos por año de lanzamiento para facilitar la regresión lineal.
Evaluar si se debe restringir el rango de años para entrenar la regresión
Experimento:
Se utiliza la regresión lineal, donde la variable independiente es el año y la dependiente la cantidad de juegos con género "x" en ese año.
Análisis:
Se graficará el resultado de la regresión tomando la pendiente y el intercepto. Junto con esto también graficaremos los puntos utilizados para realizar la regresión, con el objetivo de comparar visualmente la predicción con los datos reales.
Se calculará el Error Absoluto Medio y el coeficiente de determinación R2 para decidir si la técnica tuvo un buen desempeño al predecir los valores.
Otra forma de frasearlo es plantear la posibilidad de que un desarrollador pueda hallar grupos de juegos bajo ciertos valores similares con respecto a estos atributos escogidos.
Por ejemplo, ¿podemos hacer un grupo "Best Sellers" para juegos con muchas recomendaciones, donde la mayoría son positivas? ¿Podemos encontrar un arquetipo del estilo "Niche Games", basada en juegos buenos pero con pocas recomendaciones?
Preprocesamiento:
Experimento:
Aplicamos PCA a los datos, para obtener una visualización de éstos en dimensión reducida (en este caso, 2D).
Buscamos con el método del codo la cantidad de Clusters para el problema, y aplicamos algún clasificador para encontrar dichos Clusters: en este caso se usará K-means, Aglomerative Clustering por método Ward, y DBSCAN (tres métodos con acercamientos distintos a la manera en que realizan clustering).
Análisis:
Por método del codo se buscará la cantidad de clusters en K-means.
Se visualizarán los Clusters obtenidos por Clustering Jerárquico de método 'Linkage: Ward', y se definirá la cantidad de Clusters mediante una altura de corte pertinente.
Se aplicará DBSCAN a los datos, en base a un valor eps obtenido analíticamente, y un valor minPts estándar.
Se va a calcular el Coeficiente de Silhouette para evaluar la calidad de los Clusters generados.
# @title
from sklearn.metrics import f1_score, recall_score, precision_score
from sklearn.model_selection import train_test_split
import numpy as np
def run_classifier(clf, X, y, num_tests=100):
metrics = {'precision': [], 'recall': [], 'f1-score': []}
for _ in range(num_tests):
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.30, random_state=15, stratify=y)
clf.fit(X_train, y_train)
predictions = clf.predict(X_test)
metrics['precision'].append(precision_score(y_test, predictions,average='weighted', zero_division=0))
metrics['recall'].append(recall_score(y_test, predictions, average='weighted', zero_division=0))
metrics['f1-score'].append(f1_score(y_test, predictions, average='weighted', zero_division=0))
return metrics
# @title Clasificadores elegidos
from sklearn.dummy import DummyClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.naive_bayes import GaussianNB # naive bayes
from sklearn.neighbors import KNeighborsClassifier #kNN
from sklearn.svm import SVC # support vector machine
c0 = ("Base Dummy", DummyClassifier(strategy='stratified'))
c1 = ("Decision Tree", DecisionTreeClassifier(max_depth=5))
c2 = ("Gaussian Naive Bayes", GaussianNB())
c3 = ("KNN", KNeighborsClassifier(n_neighbors=10))
#c4 = ("Support Vector Machines", SVC())
classifiers = [c0, c1, c2, c3]
# @title Predicción de la cantidad de dueños basándose en el precio y la puntuación en Metacritic
class_counts = df_games['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games[df_games['estimated_owners'].isin(valid_classes)]
#separando atributos predictivos (X) del atributo objetivo (y)
X = data_filtered[['price','metacritic_score']].values
y = data_filtered['estimated_owners'].values
results = {}
for name, clf in classifiers:
metrics = run_classifier(clf, X, y) # hay que implementarla en el bloque anterior.
results[name] = metrics
print("----------------")
print("Resultados para clasificador: ", name)
print("Precision promedio:", np.array(metrics['precision']).mean())
print("Recall promedio:", np.array(metrics['recall']).mean())
print("F1-score promedio:", np.array(metrics['f1-score']).mean())
print("----------------\n\n")
---------------- Resultados para clasificador: Base Dummy Precision promedio: 0.45293180402442973 Recall promedio: 0.45299701867252473 F1-score promedio: 0.4529592467956489 ---------------- ---------------- Resultados para clasificador: Decision Tree Precision promedio: 0.6047336565639093 Recall promedio: 0.7040640200847325 F1-score promedio: 0.6394325825118907 ---------------- ---------------- Resultados para clasificador: Gaussian Naive Bayes Precision promedio: 0.5723978257617705 Recall promedio: 0.5142397614938021 F1-score promedio: 0.5001481621798194 ---------------- ---------------- Resultados para clasificador: KNN Precision promedio: 0.6050389965558003 Recall promedio: 0.7016318845127886 F1-score promedio: 0.635961224535507 ----------------
# @title Matriz de confusión
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
import numpy as np
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
# Filtramos las clases válidas
class_counts = df_games['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games[df_games['estimated_owners'].isin(valid_classes)]
# Separando atributos predictivos (X) del atributo objetivo (y)
X = data_filtered[['price', 'metacritic_score']].values
y = data_filtered['estimated_owners'].values
clf = DecisionTreeClassifier(max_depth=5)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.30, random_state=15, stratify=y)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
classes = unique_labels(y_test, y_pred)
conf_matrix = confusion_matrix(y_test, y_pred, labels=classes)
display = ConfusionMatrixDisplay(conf_matrix, display_labels=classes)
# Crear la figura y el eje para la matriz de confusión
fig, ax = plt.subplots(figsize=(9, 8))
display.plot(ax=ax)
# Rotar las etiquetas del eje x
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
# Mostrar el gráfico
plt.show()
# @title Predicción de la cantidad de dueños basándose en el precio y las reseñas positivas
class_counts = df_games['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games[df_games['estimated_owners'].isin(valid_classes)]
X1 = data_filtered[['price','positive']].values
y1 = data_filtered['estimated_owners'].values
results1 = {}
for name, clf in classifiers:
metrics = run_classifier(clf, X1, y1) # hay que implementarla en el bloque anterior.
results1[name] = metrics
print("----------------")
print("Resultados para clasificador: ", name)
print("Precision promedio:", np.array(metrics['precision']).mean())
print("Recall promedio:", np.array(metrics['recall']).mean())
print("F1-score promedio:", np.array(metrics['f1-score']).mean())
print("----------------\n\n")
---------------- Resultados para clasificador: Base Dummy Precision promedio: 0.4530556807642149 Recall promedio: 0.45343009571630305 F1-score promedio: 0.4532378235864337 ---------------- ---------------- Resultados para clasificador: Decision Tree Precision promedio: 0.7497584274632675 Recall promedio: 0.7819708143731368 F1-score promedio: 0.7573347346223646 ---------------- ---------------- Resultados para clasificador: Gaussian Naive Bayes Precision promedio: 0.2627310747470592 Recall promedio: 0.24435116899419415 F1-score promedio: 0.1579333562389433 ---------------- ---------------- Resultados para clasificador: KNN Precision promedio: 0.7366627779636193 Recall promedio: 0.7740467597677704 F1-score promedio: 0.7487414539848498 ----------------
# @title Matriz de confusión
class_counts = df_games['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games[df_games['estimated_owners'].isin(valid_classes)]
X1 = data_filtered[['price','positive']].values
y1 = data_filtered['estimated_owners'].values
clf = DecisionTreeClassifier(max_depth=5)
X_train, X_test, y_train, y_test = train_test_split(X1, y1, test_size=0.30, random_state=15, stratify=y1)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
classes = unique_labels(y_test, y_pred)
conf_matrix = confusion_matrix(y_test, y_pred, labels=classes)
display = ConfusionMatrixDisplay(conf_matrix, display_labels=classes)
# Crear la figura y el eje para la matriz de confusión
fig, ax = plt.subplots(figsize=(9, 8))
display.plot(ax=ax)
# Rotar las etiquetas del eje x
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
# Mostrar el gráfico
plt.show()
# @title Predicción de la cantidad de dueños basándose en la fecha de lanzamiento y las recomendaciones
# Hacemos una copia del DataFrame original
df_games_copy = df_games.copy()
# Convertimos la columna release_date a datetime
df_games_copy['release_date'] = pd.to_datetime(df_games_copy['release_date'], errors='coerce')
# Extraemos características numéricas de la fecha
df_games_copy['year'] = df_games_copy['release_date'].dt.year
df_games_copy['month'] = df_games_copy['release_date'].dt.month
df_games_copy['day'] = df_games_copy['release_date'].dt.day
# Filtramos las clases válidas
class_counts = df_games_copy['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games_copy[df_games_copy['estimated_owners'].isin(valid_classes)]
# Eliminamos las filas con NaN en las características seleccionadas
data_filtered = data_filtered.dropna(subset=['year', 'month', 'day', 'recommendations'])
# Preparamos las características y la variable objetivo
X2 = data_filtered[['year', 'month', 'day', 'recommendations']].values
y2 = data_filtered['estimated_owners'].values
results2 = {}
for name, clf in classifiers:
metrics = run_classifier(clf, X2, y2) # hay que implementarla en el bloque anterior.
results2[name] = metrics
print("----------------")
print("Resultados para clasificador: ", name)
print("Precision promedio:", np.array(metrics['precision']).mean())
print("Recall promedio:", np.array(metrics['recall']).mean())
print("F1-score promedio:", np.array(metrics['f1-score']).mean())
print("----------------\n\n")
---------------- Resultados para clasificador: Base Dummy Precision promedio: 0.45303906592942894 Recall promedio: 0.453051545582928 F1-score promedio: 0.4530403169242495 ---------------- ---------------- Resultados para clasificador: Decision Tree Precision promedio: 0.616086940999507 Recall promedio: 0.6820178879648516 F1-score promedio: 0.6055770806871238 ---------------- ---------------- Resultados para clasificador: Gaussian Naive Bayes Precision promedio: 0.12177468346275867 Recall promedio: 0.2011611485956379 F1-score promedio: 0.09135724986895451 ---------------- ---------------- Resultados para clasificador: KNN Precision promedio: 0.6166905569975587 Recall promedio: 0.6709948219049114 F1-score promedio: 0.62973466092412 ----------------
# @title Matriz de confusión
# Hacemos una copia del DataFrame original
df_games_copy = df_games.copy()
# Convertimos la columna release_date a datetime
df_games_copy['release_date'] = pd.to_datetime(df_games_copy['release_date'], errors='coerce')
# Extraemos características numéricas de la fecha
df_games_copy['year'] = df_games_copy['release_date'].dt.year
df_games_copy['month'] = df_games_copy['release_date'].dt.month
df_games_copy['day'] = df_games_copy['release_date'].dt.day
# Filtramos las clases válidas
class_counts = df_games_copy['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games_copy[df_games_copy['estimated_owners'].isin(valid_classes)]
# Eliminamos las filas con NaN en las características seleccionadas
data_filtered = data_filtered.dropna(subset=['year', 'month', 'day', 'recommendations'])
# Preparamos las características y la variable objetivo
X2 = data_filtered[['year', 'month', 'day', 'recommendations']].values
y2 = data_filtered['estimated_owners'].values
clf = DecisionTreeClassifier(max_depth=5)
X_train, X_test, y_train, y_test = train_test_split(X2, y2, test_size=0.30, random_state=15, stratify=y2)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
classes = unique_labels(y_test, y_pred)
conf_matrix = confusion_matrix(y_test, y_pred, labels=classes)
display = ConfusionMatrixDisplay(conf_matrix, display_labels=classes)
# Crear la figura y el eje para la matriz de confusión
fig, ax = plt.subplots(figsize=(9, 8))
display.plot(ax=ax)
# Rotar las etiquetas del eje x
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
# Mostrar el gráfico
plt.show()
# @title Predicción de la cantidad de dueños basándose en reseñas positivas y negativas
class_counts = df_games['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games[df_games['estimated_owners'].isin(valid_classes)]
X3 = data_filtered[['positive','negative']].values
y3 = data_filtered['estimated_owners'].values
results3 = {}
for name, clf in classifiers:
metrics = run_classifier(clf, X3, y3) # hay que implementarla en el bloque anterior.
results3[name] = metrics
print("----------------")
print("Resultados para clasificador: ", name)
print("Precision promedio:", np.array(metrics['precision']).mean())
print("Recall promedio:", np.array(metrics['recall']).mean())
print("F1-score promedio:", np.array(metrics['f1-score']).mean())
print("----------------\n\n")
---------------- Resultados para clasificador: Base Dummy Precision promedio: 0.45296304648569724 Recall promedio: 0.4532806370626078 F1-score promedio: 0.4531167358785427 ---------------- ---------------- Resultados para clasificador: Decision Tree Precision promedio: 0.7029434610039946 Recall promedio: 0.7033971442021025 F1-score promedio: 0.68788219727119 ---------------- ---------------- Resultados para clasificador: Gaussian Naive Bayes Precision promedio: 0.10356141353952113 Recall promedio: 0.2256786442805586 F1-score promedio: 0.1153237190476415 ---------------- ---------------- Resultados para clasificador: KNN Precision promedio: 0.6975135052229418 Recall promedio: 0.6999450808096661 F1-score promedio: 0.6850103852704434 ----------------
# @title Matriz de confusión
class_counts = df_games['estimated_owners'].value_counts()
valid_classes = class_counts[class_counts >= 2].index
data_filtered = df_games[df_games['estimated_owners'].isin(valid_classes)]
X3 = data_filtered[['positive','negative']].values
y3 = data_filtered['estimated_owners'].values
clf = DecisionTreeClassifier(max_depth=5)
X_train, X_test, y_train, y_test = train_test_split(X3, y3, test_size=0.30, random_state=15, stratify=y3)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
classes = unique_labels(y_test, y_pred)
conf_matrix = confusion_matrix(y_test, y_pred, labels=classes)
display = ConfusionMatrixDisplay(conf_matrix, display_labels=classes)
# Crear la figura y el eje para la matriz de confusión
fig, ax = plt.subplots(figsize=(9, 8))
display.plot(ax=ax)
# Rotar las etiquetas del eje x
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
# Mostrar el gráfico
plt.show()
Nuevamente, con respecto a los clasificadores seleccionados, ignoraremos Base Dummy al ser un método básico para clasificar, presente más que nada para referencia (asegurarnos de tener un mínimo aproximado para los valores de Precision, Recall y F1-Score).
En el caso de Gaussian Naive Bayes, se puede observar un desempeño ineficiente. Este resultado se pudo haber dado por una posible dependencia entre los atributos, lo cual tendría conflictos con la metodología de la técnica usada.
Es por esto que interpretaremos y analizaremos la información obtenida programáticamente utilizando los clasificadores Decision Tree y KNN.
Con respecto a los datos, se observan que los atributos se encuentran en clases desbalanceadas, por lo que, para todo intento de estudio con estos clasificadores, las métricas como Accuracy, Precision, Recall y F1-Score están dando resultados dominados por la clase [0, 20000]. No se tuvo el tiempo suficiente para seguir experimentando habiendo hecho un subsampling (donde suponemos que se obtendrían resultados más equilibrados) y comparar resultados.
Además, a la hora de obtener resultados, hemos únicamente clasificado para cuatro pares de atributos, habiendo otros como "Average Playtime" (tiempo promedio de juego por los usuarios) o "User Score" (porcentaje de las reviews positivas con respecto al total). No consideramos las combinaciones escogidas como totalmente concluyentes, pero nos entrega una buena noción de qué parámetros influyen más a la hora de entregar información a un desarrollador de juegos.
En vista de los resultados programáticos que hemos obtenido, a un desarrollador de juegos le es más relevante la información obtenida a partir de las clases “precio del juego”, “cantidad de reseñas positivas” y "cantidad de reseñas negativas", información útil para hacer que su juego tenga más impacto y que más usuarios se conviertan en posibles compradores. Con esto, el desarrollador tendrá una idea más clara de en qué parámetros enfocarse si desea llegar a un rango de ventas específico, y con eso comparar si su producto fue un éxito o un fracaso en la fecha de lanzamiento.
Con este método es complicado comparar los "Pesos" que tiene cada atributo al momento de predecir las ventas, ya que la cantidad de clasificaciones manuales sería muy alta. La mejor forma de abordar este problema sería realizar algún metodo de selección de atributos (PCA) o de reducción de atributos.
A continuación se muestra el la experimentación hecha para responder la pregunta 2.
# @title Preprocesamiento
def preprocessing(dataframe, genre, begin_year = 1997, target_year = 2025):
# Asegurarse de que la columna 'release_date' esté en formato de fecha
dataframe['release_date'] = pd.to_datetime(dataframe['release_date'], errors='coerce')
# Extraer el año de la fecha de lanzamiento
dataframe['year'] = dataframe['release_date'].dt.year
dataframe = dataframe[(dataframe['year'] >= begin_year) & (target_year >= dataframe['year'])]
# Filtrar los datos para eliminar aquellos sin año de lanzamiento válido
dataframe = dataframe.dropna(subset=['year'])
# Extraemos los géneros
dataframe = dataframe[dataframe['genres'].str.contains(genre, na=False)]
result = dataframe.sort_values(by=["year"])[["year","genres"]]
instances = result.groupby("year")
# grupos [[años],[cantidades]]
groups = [[],[]]
for a, b in instances:
groups[0].append(a)
groups[1].append(b["genres"].size)
#print(len(groups[0]))
#print(len(groups[1]))
final_result = pd.DataFrame({"year": groups[0], "quantity": groups[1]})
return final_result
Para el preprocesamiento convertiremos las fechas en la columna "Release date" en un formato numérico con el cual podamos trabajar. Luego, para un género elegido seleccionaremos los juegos entre ciertos años. Para ejemplicar, tomaremos los juegos de del género de acción (Action) lanzados entre 1997 y 2025 (este último año puede deberse a juegos que están en acceso anticipado y serán lanzados oficialmente en dicha fecha).
# @title Gráfico cantidad de juegos por género "Action" a través de los años
test1 = preprocessing(df_games, "Action")
X = []
y = []
X.extend(test1['year'].values)
y.extend(test1['quantity'].values)
X = np.array(X).reshape(-1, 1)
y = np.array(y).reshape(-1, 1)
plt.xlabel('Años')
plt.ylabel('Cantidad de juegos"')
plt.title('Cantidad de juegos por género "Action" a través de los años')
plt.scatter(X, y)
plt.show()
Se puede observar un crecimiento sostenido, casi lineal, desde el año 2013 hasta el 2024, con una pequeña baja para el 2019. Los años previos a 2013 podrían obstaculizar el análisis al tener valores muy bajos con respecto a los recientes. Pero, ¿qué pasa con 2024 o 2025? Ambos años podrían ser obstáculos para el análisis. El primer año porque aún no ha terminado, por lo que no representará correctamente la cantidad de videojuegos lanzados. Mientras que el segundo aún no ha empezado.
# @title Gráfico cantidad de juegos por género "Action" a través de los años restringidos
test2 = preprocessing(df_games, "Action", 2013, 2023)
X = []
y = []
X.extend(test2['year'].values)
y.extend(test2['quantity'].values)
X = np.array(X).reshape(-1, 1)
y = np.array(y).reshape(-1, 1)
plt.xlabel('Años')
plt.ylabel('Cantidad de juegos"')
plt.title('Cantidad de juegos por género "Action" entre 2013 y 2023')
plt.scatter(X, y)
plt.show()
Si acotamos el rango de los años, se evidencia de manera más clara un comportamiento lineal, lo que respalda la decisión de utilizar una regresión lineal.
# @title Experimento (regresión lineal)
# Supongamos que tus archivos se llaman 'data1.csv', 'data2.csv', ..., 'data10.csv'
# resultadosHeap/Fib
def fitting(dataframe, x_column, y_column, begin_year, target_year):
# Creamos una lista para almacenar los resultados de cada regresión lineal
X = []
y = []
# Definimos nuestras variables independientes y dependientes
X.extend(dataframe[x_column].values) #independiente año
y.extend(dataframe[y_column].values) #dependiente cantidad de juegos por el genero pedido
X = np.array(X).reshape(-1, 1)
y = np.array(y).reshape(-1, 1)
# Creamos una instancia de la clase LinearRegression
regressor = LinearRegression()
# Entrenamos nuestro modelo con los datos de entrenamiento
regressor.fit(X, y)
# Almacenamos los resultados
resultados = (regressor.coef_[0][0], regressor.intercept_[0])
pendiente = resultados[0]
intercepto = resultados[1]
# Creamos un array de valores x basado en los datos originales
x_plot = np.linspace(begin_year, target_year)
# Calculamos los valores y usando la ecuación de la línea
y_plot = pendiente * x_plot + intercepto
print("Valor estimado para " + str(target_year) + ": " + str(pendiente * target_year + intercepto))
print("Error absoluto medio:", mean_absolute_error(y, regressor.predict(X)))
print("Coeficiente de determinación (R^2):", r2_score(y, regressor.predict(X)))
plt.xlabel('Años')
plt.ylabel('Cantidad de juegos')
plt.title('Regresión lineal para la cantidad de juegos' + '\n' + 'por género "Action" entre ' + str(begin_year) + ' y ' + str(target_year))
plt.scatter(X, y)
plt.plot(x_plot, y_plot, color='red')
plt.show()
return resultados
# @title Gráfico regresión lineal cantidad de juegos por género "Action" a través de los años
fitting1 = fitting(test1, "year", "quantity", 1997, 2025)
print('')
fitting2= fitting(test2, "year", "quantity", 2013, 2025)
Valor estimado para 2025: 3171.744827586226 Error absoluto medio: 1014.5130966536456 Coeficiente de determinación (R^2): 0.45026782143598865
Valor estimado para 2025: 6820.518181818072 Error absoluto medio: 144.1818181817547 Coeficiente de determinación (R^2): 0.986741767958694
Para el primer gráfico, si tomamos en cuenta todos los datos, visualmente la regresión lineal no predice de buena manera la cantidad de videojuegos para el 2025, ya que la pendiente calculada es mucho más baja de lo esperado y no alcanza a los valores reales. Esto puede verse reflejado en el coeficiente de determinación, el cual está muy alejado de uno.
Si no consideramos los años anteriores al 2013 y posteriores al 2023, se observa una regresión con mejor desempeño y se nota de una mejor manera el comportamiento lineal de los datos. El error absoluto medio es mucho menor al del gráfico anterior y el coeficiente de determinación muestra una predicción mucho más acertada, por lo que este modelo es el que mejor predice el valor buscado.
Tras ver el gráfico de los datos sin retringir los años vemos que, a simple vista, se asemeja a un crecimiento exponencial. Es por esto que usaremos regresión exponencial para ver si nos da una predicción aún más acertada.
# @title Experimiento (regresión exponencial)
def fittingExpNormalized(dataframe, x_column, y_column, begin_year, target_year):
# Creamos una lista para almacenar los resultados de cada regresión lineal
X = dataframe[x_column].values
y = dataframe[y_column].values
# Normalizamos los años para evitar problemas de desbordamiento
X_normalized = (X - np.mean(X)) / np.std(X)
# Tomamos el logaritmo de y
y_log = np.log(y)
# Creamos una instancia de la clase LinearRegression
regressor = LinearRegression()
# Entrenamos nuestro modelo con los datos de entrenamiento
regressor.fit(X_normalized.reshape(-1, 1), y_log.reshape(-1, 1))
# Almacenamos los resultados
pendiente = regressor.coef_[0][0]
intercepto = regressor.intercept_[0]
# Creamos un array de valores x basado en los datos originales
x_plot = np.linspace(begin_year, target_year, 100)
x_plot_normalized = (x_plot - np.mean(X)) / np.std(X)
# Calculamos los valores y usando la ecuación de la línea
y_plot = np.exp(intercepto) * np.exp(pendiente * x_plot_normalized)
y_log_pred = regressor.predict(X_normalized.reshape(-1, 1))
y_pred = np.exp(y_log_pred)
mae_original = mean_absolute_error(y, y_pred)
print("Valor estimado para " + str(target_year) + ": " + str(np.exp(intercepto) * np.exp(pendiente * (target_year - np.mean(X)) / np.std(X))))
print("Error absoluto medio:",mae_original)
print("Coeficiente de determinación (R^2):", r2_score(y, y_pred))
plt.xlabel('Años')
plt.ylabel('Cantidad de juegos')
plt.title('Regresión exponencial para la cantidad de juegos' + '\n' + 'por género "Action" entre ' + str(begin_year) + ' y ' + str(target_year))
plt.scatter(X, y)
plt.plot(x_plot, y_plot, color='red')
plt.show()
return (pendiente, intercepto)
# @title Gráfico regresión lineal cantidad de juegos por género "Action" a través de los años restringidos
fitting1 = fittingExpNormalized(test1, "year", "quantity", 1997, 2025)
print('')
test3 = preprocessing(df_games, "Action", 1997, 2023)
fitting3 = fittingExpNormalized(test3, "year", "quantity", 1997, 2025)
print('')
fitting2 = fittingExpNormalized(test2, "year", "quantity", 2013, 2025)
Valor estimado para 2025: 3961.5252809066546 Error absoluto medio: 1077.242431970045 Coeficiente de determinación (R^2): 0.014076494783377913
Valor estimado para 2025: 29187.729019722865 Error absoluto medio: 706.374517885078 Coeficiente de determinación (R^2): 0.02550310201499939
Valor estimado para 2025: 14614.965472725622 Error absoluto medio: 789.8463072633757 Coeficiente de determinación (R^2): 0.5943960592235875
Como era de esperar, los resultados no se acercan lo suficiente a los datos reales como para considerar este tipo de regresión, devolviendo valores de error absoluto medio muy altos y coeficientes $R^2$ muy bajos con respecto a el modelo lineal.
Volviendo a la pregunta inicial: ¿Podemos predecir la cantidad de juegos de un género que saldrán un año determinado? La respuesta es sí, la cantidad de videojuegos lanzados por año se puede predecir y con una buena presición. Sin embargo, para obtener una buena predicción será necesario acotar el rango de los años para obtener una muestra más representativa puesto que antiguamente no se usaba tanto Steam como hoy en día.
Lo primero que haremos será normalizar los datos antes de trabajarlos. Esto es, el normalizador recibirá los datos de la tabla, calculará la media y desviación estándar, y normalizará las tuplas de modo que los valores queden con Media 0, Desviación Estándar 1. Esto nos permitirá trabajar los datos para encontrar los clusters.
# @title Preprocesamiento
import pandas as pd
import numpy as np
# Eliminar filas con valores nulos en columnas críticas
df_games.dropna(subset=['price', 'genres', 'release_date'], inplace=True)
# Filtrar filas donde 'positive', 'negative' y recommendations sean mayores que 0
df_games = df_games[(df_games['positive'] > 0) & (df_games['negative'] > 0) & (df_games['recommendations'] > 0)]
# Filtrar datos relevantes
df_games = df_games[['app_id', 'name', 'genres', 'price', 'positive', 'negative', 'recommendations']]
# Obtener género principal
df_games['main_genre'] = df_games['genres'].apply(lambda x: x.split(',')[0].strip())
df_games
| app_id | name | genres | price | positive | negative | recommendations | main_genre | |
|---|---|---|---|---|---|---|---|---|
| 10 | 1026420 | WARSAW | Indie,RPG | 23.99 | 589 | 212 | 427 | Indie |
| 15 | 22670 | Alien Breed 3: Descent | Action | 9.99 | 349 | 134 | 285 | Action |
| 17 | 346560 | Hero of the Kingdom II | Adventure,Casual,Indie,RPG | 7.99 | 2046 | 120 | 1615 | Adventure |
| 22 | 434030 | Aerofly FS 2 Flight Simulator | Action,Indie,Racing,Simulation | 37.49 | 1490 | 408 | 1831 | Action |
| 24 | 2073470 | Kanjozoku Game レーサー | Massively Multiplayer,Racing,Simulation,Sports | 5.99 | 392 | 57 | 493 | Massively Multiplayer |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 84311 | 2152790 | UNITED 1944 | Action,Early Access | 23.99 | 160 | 30 | 179 | Action |
| 84325 | 2052410 | WITCH ON THE HOLY NIGHT | Adventure | 35.99 | 658 | 13 | 740 | Adventure |
| 84326 | 2287520 | Five Nights at Freddy's: Help Wanted 2 | Indie,Simulation | 39.99 | 862 | 84 | 1067 | Indie |
| 84454 | 2499800 | Garten of Banban 6 | Action,Adventure,Casual,Indie | 9.99 | 280 | 123 | 403 | Action |
| 84963 | 2487350 | Alex Jones: NWO Wars | Action,Indie | 17.76 | 596 | 32 | 780 | Action |
13208 rows × 8 columns
Una vez tengamos los datos normalizados, podemos aplicar PCA a los datos para encontrar la mejor combinación de atributos a trabajar. Así, podemos encontrar los atributos a entender que sirven más para un desarrollador a la hora de crear su juego.
# @title PCA
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
X = df_games[['price', 'positive', 'negative', 'recommendations']]
# Escriba su código aquí
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X)
# Visualizar los datos
plt.scatter(X_pca[:, 0], X_pca[:, 1])
plt.show()
Sumado a esto, aplicaremos el método del codo para encontrar cuál es la cantidad de clusters ideal para estos datos. Visualmente podemos reconocer un gran cluster, seguido de 3 de menor tamaño, por lo que es esperado que el análisis del gráfico nos entregue resultados similares (sería de esperar que el codo se encuentre entre 3 y 4 clusters).
# @title Eligiendo el número de clusters óptimo
from sklearn.cluster import KMeans
# Escriba su código aquí
sse = []
clusters = list(range(1, 16))
for k in clusters:
kmeans = KMeans(n_clusters=k, n_init='auto').fit(X_pca)
sse.append(kmeans.inertia_)
plt.plot(clusters, sse, marker="o")
plt.title("Metodo del codo, desde 1 hasta 15 clusters")
plt.grid(True)
plt.show()
Según habíamos dicho antes de manipular los datos con el método del codo, el gráfico se alinea con lo que habíamos concluído, y lo ideal sería considerar 4 clusters para este problema. Si bien sería posible trabajar con 3 clusters en vez de 4, el SSE baja lo suficiente para considerar trabajar con 4.
Con esto dicho, decidimos escoger 4 clusters y, habiendo decidido esto, podemos aplicar un clasificador como K-means para visualizar los clusters escogidos.
# @title K-means
random_state = 20
kmeans_4 = KMeans(n_clusters=4, n_init='auto', random_state=random_state).fit(X_pca)
centers_4 = kmeans_4.cluster_centers_
labels = kmeans_4.labels_
plt.scatter(X_pca[:, 0], X_pca[:, 1], c=kmeans_4.labels_)
plt.scatter(centers_4[:,0], centers_4[:,1], s=200, facecolors='none', edgecolors='r')
plt.title("K-Means")
plt.show()
Considerando que el gráfico está "estirado" (es decir, una distancia d en el eje x es mucho menos significativa que en el eje y), los 4 Clusters son fáciles de divisar, aunque uno de los clusters posee mucho ruido, y el otro es prácticamente un outlier.
En base a lo recién realizado, se seleccionan juegos de cada cluster para buscar identificar patrones en los juegos de cada cluster.
# @title
# Añade las etiquetas de los clusters al DataFrame original
df_games['cluster'] = labels
# Asigna nombres a los clusters basados en el análisis
cluster_names = {
0: "Test0",
1: "Test1",
2: "Test2",
3: "Test3"
}
# Mapea los nombres de los clusters a las etiquetas
df_games['cluster_name'] = df_games['cluster'].map(cluster_names)
# Mostrar ejemplos de cada cluster
for name, group in df_games.groupby('cluster_name'):
print(f"Cluster: {name}")
print(group[['name', 'main_genre', 'price', 'positive', 'negative', 'recommendations']].head())
print("\n")
Cluster: Test0
name main_genre price positive \
10 WARSAW Indie 23.99 589
15 Alien Breed 3: Descent Action 9.99 349
17 Hero of the Kingdom II Adventure 7.99 2046
22 Aerofly FS 2 Flight Simulator Action 37.49 1490
24 Kanjozoku Game レーサー Massively Multiplayer 5.99 392
negative recommendations
10 212 427
15 134 285
17 120 1615
22 408 1831
24 57 493
Cluster: Test1
name main_genre price positive negative \
46158 Counter-Strike: Global Offensive Action 0.0 5764420 766677
recommendations
46158 3441592
Cluster: Test2
name main_genre price positive negative \
1289 Garry's Mod Indie 9.99 822326 29004
2904 Tom Clancy's Rainbow Six® Siege Action 19.99 312232 64137
4287 Tom Clancy's Rainbow Six® Siege Action 19.99 312816 64201
5993 ARK: Survival Evolved Action 29.99 461567 98701
8009 Cyberpunk 2077 RPG 59.99 391643 129925
recommendations
1289 725462
2904 899435
4287 899455
5993 435328
8009 458744
Cluster: Test3
name main_genre price positive negative \
47 Far Cry® 5 Action 59.99 100620 25286
57 Forza Horizon 4 Racing 59.99 122539 15095
96 Oxygen Not Included Indie 24.99 82902 3014
736 Apex Legends™ Action 0.00 415524 66608
828 American Truck Simulator Indie 19.99 104521 3859
recommendations
47 114588
57 126316
96 80467
736 1000
828 87888
En general los Clusters "Test[$i$]", para $i$ = {0, 2, 3}, no dan mucha información sobre los juegos que contienen, pero suele estar muy relacionado con la magnitud de recomendaciones y reviews en general que poseen. Por otro lado, notamos que el juego "Counter Strike: Global Offensive" es nuestro outlier a la derecha del gráfico, con una cantidad absurda de recomendaciones y de reviews positivas en comparación con los otros clusters.
A continuación, por la alta cantidad de datos se decidió extraer un sample al azar del 10% del total de los datos. Con esto, podemos aplicar Aglomerative Clustering y DBSCAN. Dicho esto, se comenzó realizando el sampling y trabajando dichos datos para Linkage: Ward.
# @title Sample
# Muestreo aleatorio del 10% de los datos
sample_fraction = 0.1
df_sample = df_games.sample(frac=sample_fraction, random_state=42)
X_sample = df_sample[['price', 'positive', 'negative', 'recommendations']]
X_sample_pca = pca.transform(X_sample)
# @title
from scipy.cluster.hierarchy import dendrogram, linkage
complete = linkage(X_sample, method="complete")
single = linkage(X_sample, method="single")
average = linkage(X_sample, method="average")
ward = linkage(X_sample, method="ward")
# @title
dendrogram(ward)
plt.title("Linkage: Ward")
plt.show()
# @title
dendrogram(ward)
plt.title("Linkage: Ward")
plt.axhline(y=400000, color='r', linestyle='--')
plt.show()
Notemos que, en la parte superior del sample, podemos ver que la franja azul se divide en dos clusters para una altura superior a 800 mil. Se realiza esta aclaración puesto que es difícil ver a simple vista el cluster izquierdo, el cual está pegado al eje y, dificultando el poder verlo.
Luego, viendo la distancia de las ramas a la raíz del dendograma, se consideró pertinente elegir una altura de corte en x = 400 mil. Con esto es directo que la cantidad de clusters resultantes es 3.
# @title
from sklearn.cluster import AgglomerativeClustering
# Escriba su código aquí
ward_all = AgglomerativeClustering(n_clusters=None, linkage="ward", distance_threshold=400000).fit(X_sample_pca)
print(ward_all.n_clusters_)
3
Se puede ver que una rápida verificación programática nos entrega el mismo resultado con respecto al número de clusters.
Luego, podemos usar el mismo linkage para Clustering Jerárquico:
# @title
plt.scatter(X_sample_pca[:, 0], X_sample_pca[:, 1], c=ward_all.labels_)
plt.title("Hierarchical: ward, 3 clusters")
plt.show()
Esta vez, los clusters claramente tienen una estructura que se asemeja más a un clustering en función de densidades que sobre distancias. Sin embargo, una consecuencia directa de esto es el tercer cluster (color morado), cuyos valores están muy dispersos a lo largo del gráfico (tiene altos niveles de ruido).
Finalmente, revisaremos también el modelo de clasificador DBSCAN, el cual trabaja en base a densidad de nodos:
# @title
from sklearn.neighbors import NearestNeighbors
import numpy as np
nbrs = NearestNeighbors(n_neighbors=3).fit(X_sample)
distances, indices = nbrs.kneighbors(X_sample)
distances = np.sort(distances, axis=0)
distances = distances[:,1]
fig, ax = plt.subplots()
ax.axhline(y=5000, color='r', linestyle='--') # Ajuste el valor para y
ax.plot(distances)
plt.show()
Visualmente podemos determinar que el valor de eps ronda los 5000. Se guiará la construcción del clasificador DBSCAN bajo lo visto en Laboratorios, tal que usamos minPts igual a 5. Así:
# @title
from sklearn.cluster import DBSCAN
eps = 5000
min_samples = 5
dbscan = DBSCAN(eps=eps, min_samples=min_samples).fit(X_sample_pca)
plt.scatter(X_sample_pca[:,0], X_sample_pca[:,1], c=dbscan.labels_)
plt.title(f"DBSCAN: eps={eps}, min_samples={min_samples}")
plt.show()
Notamos que DBSCAN encuentra 4 clusters dentro de los datos proporcionados, donde es difícil concluir mucho sobre el cluster de color morado, pues sus valores están tan dispersos que podrían confundirse con ruido. Sin embargo, los otros 3 clusters se ven bien definidos.
Finalmente, se evaluarán los clasificadores utilizados mediante el Coeficiente de Silhouette (se recuerda que, entre más cercano este coeficiente a 1.0, mejor es el modelo):
# @title Coeficiente de Silhouette
from sklearn.metrics import silhouette_score
print("Dataset X K-Means 4\t", silhouette_score(X_pca, kmeans_4.labels_))
print("Dataset X ward all\t", silhouette_score(X_sample_pca, ward_all.labels_))
_filter_label = dbscan.labels_ >= 0
print("Dataset X DBSCAN\t", silhouette_score(X_sample_pca[_filter_label], dbscan.labels_[_filter_label]))
Dataset X K-Means 4 0.9552412648896319 Dataset X ward all 0.9199589324743636 Dataset X DBSCAN 0.906227566420273
De esta manera, es directo que K-Means para n = 4 clusters resulta ser el clasificador más eficiente para los datos propiciados.
En base a todo lo desarrollado con respecto a esta pregunta, se considera acertado decir que los juegos del dataset, bajo los atributos escogidos, tienden a separarse en grupos distinguibles entre sí. Si bien la proporción de recomendaciones a cantidad de reviews positivas suele ser universal y muy similar entre grupos, lo importante a considerar es la cantidad de recomendaciones y en general reviews por juego individual, donde es fácil distinguir que cada grupo tiene estas cantidades en intervalos bien definidos.
Como grupo se determinó que, en un escenario hipotético donde el problema se vuelve a visitar en el futuro a causa de tener más tiempo, el problema se podría seguir trabajando bajo las siguientes aristas:
Usar clasificadores para predecir precios acorde a las características de un juego
Encontrar alguna otra metodología para responder a la primera pregunta
Abordar el tema desde la perspectiva de un consumidor
Mario Benavente: Redactar cada parte de los análisis y Exploración de datos para la pregunta 3.
Adicionalmente, todos los integrantes ayudaron a la confección del PPT a presentar ante los profesores.
Steam reviews. (2023, noviembre 9). Kaggle.com; Kaggle. https://www.kaggle.com/code/gonzafrancoandres/steam-reviews
How to do exponential and logarithmic curve fitting in Python?. Stack Overflow. https://stackoverflow.com/questions/3433486/how-to-do-exponential-and-logarithmic-curve-fitting-in-python-i-found-only-poly